Skip to content

feat: cut a release with abcd launch ship and a derived changelog#129

Merged
REPPL merged 2 commits into
mainfrom
feat/itd-67-p3-launch-ship-changelog
Jul 22, 2026
Merged

feat: cut a release with abcd launch ship and a derived changelog#129
REPPL merged 2 commits into
mainfrom
feat/itd-67-p3-launch-ship-changelog

Conversation

@REPPL

@REPPL REPPL commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Phase 3 of the derived-versioning + auto-changelog + plugin-distribution programme
(plan: .abcd/development/plans/2026-07-21-derived-version-and-changelog.md; spec: spc-11).

Fourth of five stacked PRs. Based on feat/itd-73-p2-surface-guardrail (#127).

The write-verb the whole programme hangs on did not exist: launch was dry-run-only and
Ship() stopped at WouldPublish. Verified before building:
abcd launch shipunknown command "ship", abcd changelogunknown command.

It runs, on this repo, today

$ go run ./cmd/abcd changelog
abcd changelog — REFUSED (base v0.3.0)
  decided by: iss-51
  guard:      refused
  added:      17 record(s)
    [breaking ] iss-51   context-scoped-successor-bans
    [additive ] iss-47   cli-reference-generation
    [internal ] iss-97   detect-unbounded-marker-reads  (excluded from the changelog)
    …
  refused (surface-guard):
    no surface baseline in v0.3.0 … so a break cannot be detected and the cut refuses
    rather than passing unguarded.

changelog exits 0 (a status render). launch ship exits 1 (the cut refuses).
CHANGELOG.md is untouched by both. The refusal is the phase-2 fail-closed baseline rule
firing correctly — it clears when a release is cut that contains the baseline.

Two entry points, mirroring disembark

abcd launch ship with no flag is the deterministic emit step. With
--changelog-json <file|-> it ingests the host-composed payload, runs the bijection and
writes. The host runs the composer between them — the same shape as
disembark … --principles-json.

The completeness bijection

Cited record ids must equal the cut's records minus the internal ones. No omission, no
invention. This is deliberately not SynthesizePrinciples' cite-or-be-dropped semantic:
an omitted record is a lie by omission in a durable release record, so a mismatch refuses the
whole payload and writes nothing, the way ComposePressRelease refuses an uncited
document. The payload guards do come from the principles path (size cap, unknown-field
rejection, sanitisation).

The agent controls wording and the Keep-a-Changelog section only. Version and inclusion
are deterministic.

Refusals — six kinds, all fail-closed

no-release-tag, release-in-flight, unlabelled-record, stale-intent, surface-guard,
empty-cut. Typed rather than prose-matched, so a caller acts on the kind instead of
pattern-matching English. A refused cut carries no derived version — refusing while still
emitting a number invites someone to use it.

stale-intent is the itd-94 case: an intent still in planned/ whose spec is closed is
invisible to the tree diff, so the cut would silently under-bump. It now refuses and names it.

What the two independent reviews caught — both FIX_FIRST, both fixed

  1. The heading could have been written where the release workflow would never see it.
    insertSection asserted the new heading's shape but never its position. In a
    CHANGELOG.md whose ## [Unreleased] anchor sits below an older dated section, the new
    heading lands below an already-tagged one — and auto-release.yml's grep -m1 matches the
    older heading. The cut reports success and the release is silently never tagged.
    Now the assembled bytes are scanned and the write refuses unless the new heading is the
    first dated one, naming the heading the workflow would otherwise tag.
  2. Both reviewers independently found the bijection had a hole. A record on the cut's
    removed side whose blob at the base tag carries no valid impact was dropped from the
    required set — so a supersession would never reach the release record. The security
    reviewer added the sharper half: an honest composer that cited it would then be refused
    for "inventing" it. Fixed at the canonical home (Record.InChangelog()), so the CLI
    render, the composer prompt's stated rule, and the bijection all read one predicate.

Plus two hardening nits: trailing data after the JSON document is now refused, and an
over-long cited id is described by length rather than echoed.

The composer agent

release-changelog-composer — named distinctly because the CHANGELOG and README agent
slots are occupied by two documents the loader mis-registers as agents (iss-110).
Carries prompt_version: 0.1.0, reads_untrusted_input: true, an injection-canary fixture,
and the untrusted-data contract. Logged in agents/CHANGELOG.md (the agent prompt log — not
the repo changelog).

Reuse over reinvention

The in-flight detection is Derive's, now classified by a typed RefusalKind rather than
reimplemented. The stale-intent check consumes a ScanSpecLinks index extracted from the
lint's existing spec-lifecycle traversal, which was rewritten to consume it — no third walk
of the same trees.

Verification

gofmt -l . silent · go vet ./... · go build ./... · go test ./... ·
go run ./cmd/record-lint exit 0 · both new verbs exercised on the real repo.

Tested under CI's actual condition before pushing: the check job checks out with no
tags, which is what turned #125 and #127 red. The full suite passes in a clone with its tags
deleted. That fix is merged forward into this branch.

CHANGELOG.md, go.mod, go.sum and both .github/workflows/*.yml are untouched
ADR-37's release contract is preserved, which is the entire point of writing the heading the
existing grep already matches.

REPPL added 2 commits July 21, 2026 20:31
Phase 3 of the derived-versioning programme (itd-67 / spc-11). The write-verb the
whole programme hangs on did not exist: `launch` was dry-run-only and Ship()
stopped at WouldPublish. This builds it, plus the generation flow and the preview.

TWO ENTRY POINTS, MIRRORING DISEMBARK
`abcd launch ship` with no flag is the deterministic EMIT step: the record set,
the derived version, the guardrail result, and any refusals. With
--changelog-json <file|-> it INGESTS the host-composed payload, runs the
completeness bijection, and writes the dated heading. The host runs the composer
agent between the two, exactly as it does between `disembark`'s emit and ingest.

THE COMPLETENESS BIJECTION
The cited record ids must EQUAL the cut's records minus the `internal` ones — no
omission, no invention. This is deliberately NOT the cite-or-be-dropped semantic
of SynthesizePrinciples: an omitted record is a lie by omission in a durable
release record, so a mismatch refuses the WHOLE payload and writes nothing, the
way ComposePressRelease refuses an uncited document. The payload guards do come
from the principles path (size cap, unknown-field rejection, sanitisation).

The agent controls wording and the Keep-a-Changelog section only. The version and
the inclusion set are deterministic — a four-value impact cannot express
Security/Deprecated granularity, so the section is the agent's judgement while the
bijection guards id-completeness alone.

THE REFUSALS, ALL FAIL-CLOSED
Six kinds, typed rather than prose-matched: no-release-tag, release-in-flight,
unlabelled-record, stale-intent, surface-guard, empty-cut. A refused cut carries
NO derived version — refusing and still emitting a number would invite someone to
use it. The stale-intent refuse is the itd-94 case: an intent still in planned/
whose spec is closed is invisible to the tree diff, so the cut would silently
under-bump; it now refuses and names the record.

THE COMPOSER
A new host-delegated agent, release-changelog-composer — named distinctly because
the CHANGELOG and README agent slots are occupied by two documents the loader
mis-registers as agents (iss-110). It carries prompt_version 0.1.0, an
injection-canary fixture, and the untrusted-data contract: everything it reads is
data, never instruction.

`abcd changelog` is the deterministic-only preview: next version, deciding
impact, record list, guardrail status. No agent, no prose, and a full-tree digest
test proves it writes nothing.

Reuse over reinvention: the in-flight detection is Derive's, now classified by a
typed RefusalKind rather than reimplemented; the stale-intent check consumes a
ScanSpecLinks index extracted from the lint's existing spec-lifecycle traversal
rather than adding a third walk of the same trees.

Assisted-by: Claude:claude-opus-4-8
Base automatically changed from feat/itd-73-p2-surface-guardrail to main July 22, 2026 13:11
@REPPL
REPPL merged commit 91b8917 into main Jul 22, 2026
12 checks passed
@REPPL
REPPL deleted the feat/itd-67-p3-launch-ship-changelog branch July 22, 2026 13:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant